6.11. Основы системного проектирования и масштабируемости параллелизма
Системное проектирование
Системное проектирование — это фундаментальная дисциплина, объединяющая в себе инженерное мышление, архитектурные паттерны и эмпирический опыт. Она отвечает не столько за написание кода, сколько за построение логически целостных, технически устойчивых и операционно поддерживаемых систем. В отличие от алгоритмического программирования, результат которого ограничен одной задачей, системное проектирование оперирует контекстами: взаимодействием множества подсистем, эволюцией требований, ограничениями инфраструктуры и неопределённостью внешней среды.
Параллелизм в таких системах — не просто техническая возможность выполнения нескольких вычислений одновременно, а инструмент достижения масштабируемости. При этом важно отделить понятие масштабируемости от производительности. Производительность — это абсолютная метрика: сколько операций система обрабатывает за единицу времени в текущей конфигурации. Масштабируемость — это относительная способность: как изменится производительность при добавлении ресурсов (вычислительных, сетевых, хранилищных) и при изменении нагрузки.
То, как проектируется система, определяет её пределы роста — не только в терминах пользовательской нагрузки, но и в измерении сложности сопровождения, адаптивности к новым требованиям и стоимости эксплуатации. Именно поэтому системное проектирование начинается задолго до написания первой строки кода — с чёткой формулировки целей, ограничений и критериев успеха.
Системное проектирование как процесс
Процесс системного проектирования не является линейной последовательностью шагов от анализа до реализации. Это циклическая, часто итеративная деятельность, в которой каждый этап уточняет предыдущий и формирует контекст для следующего.
Наиболее распространённая модель такого процесса включает в себя несколько ключевых фаз:
-
Определение требований — функциональных и некоторых нефункциональных. Здесь выявляются ожидания по отказоустойчивости, задержкам, пропускной способности, совместимости, конфиденциальности. Именно нефункциональные требования (NFR — non-functional requirements) задают рамки архитектурных решений. Например, требование «время отклика не более 200 мс при 95-м процентиле» уже исключает ряд решений по хранению и маршрутизации запросов.
-
Моделирование нагрузки и сценариев использования — на этом этапе строится образ реального использования: какие операции будут происходить, с какой частотой, откуда идёт трафик (внутренние или внешние клиенты, API-вызовы или веб-интерфейс), какие данные изменяются и как часто. Моделирование позволяет выявить узкие места до их появления в эксплуатации.
-
Выбор архитектурных стилей и паттернов — здесь решается, будет ли система монолитной, сервис-ориентированной, event-driven или основанной на потоках данных. Этот выбор должен соответствовать профилю нагрузки, требованиям к задержкам, существующим компетенциям команды и долгосрочной стратегией развития.
-
Прототипирование критических путей — часто игнорируемый, но решающий этап. Он включает создание минимальной реализации наиболее «чувствительных» или «рискованных» компонентов: например, системы маршрутизации запросов, механизма шардинга или алгоритма консистентного распределения данных. Результат — подтверждение или опровержение гипотез о масштабируемости.
-
Документирование архитектурных решений не менее важно, чем техническая реализация. Архитектурные решения (ADR — Architecture Decision Records) фиксируют мотивацию каждого ключевого выбора: почему была принята одна модель распределения, а не другая; почему выбран конкретный брокер сообщений; какие альтернативы рассматривались и почему они были отклонены. Без такой фиксации быстро теряется контекст, и каждое последующее изменение превращается в авантюру.
Системное проектирование — это совместная ответственность всех участников процесса: от аналитиков до DevOps-инженеров. Чем раньше специалисты начинают мыслить системно — тем выше вероятность избежать «архитектурных долгов».
Проектирование с нуля
Когда система создается с нуля, у команды есть редкая возможность — избежать наследия. Однако «чистый лист» — это возможность самостоятельно установить ограничения. На практике это означает, что ошибки проектирования на ранних этапах могут закрепиться на долгие годы и стоить значительно дороже, чем аналогичные ошибки в зрелой системе.
Первым шагом в проектировании с нуля является формулировка границ домена. Это способ минимизировать связность между компонентами. Например, в сервисе укорочения URL можно выделить три логических домена: ввод и валидация URL, хранение и индексация коротких ключей, перенаправление по запросу. Каждый из них может развиваться независимо — при условии, что между ними определены чёткие контракты взаимодействия.
Особое внимание уделяется интерфейсам — API, форматам данных, механизмам обмена, соглашениям о версионировании. В системе, проектируемой с нуля, интерфейсы должны быть стабильными с самого начала: изменение контракта между компонентами в будущем потребует синхронизированной модификации всех зависимых частей — задача, растущая экспоненциально с ростом числа компонентов.
Также важно разграничить логику приложения и инфраструктурную логику. Например, механизм репликации данных или балансировки нагрузки должен быть вынесен за пределы бизнес-кода и управляем на уровне инфраструктуры или платформы. Это позволяет изменять топологию системы без переписывания ядра.
Ключевой принцип при проектировании с нуля — разделение по осям масштабирования. Согласно модели Scale Cube (хотя мы не будем приводить её визуализацию), масштабирование можно осуществлять по трём измерениям: X — клонирование экземпляров, Y — функциональная декомпозиция, Z — разделение по данным (шардинг). Хорошо спроектированная система с самого начала предусматривает возможность движения по всем трём осям — даже если в начальной версии используется только одна.
Проектирование с учётом будущего масштабирования
Масштабируемость — это встроенная характеристика архитектуры. Чтобы система могла эффективно масштабироваться, проектировщик должен заранее учитывать ряд факторов.
Во-первых, отказ от глобального состояния. Любое состояние, которое должно быть единым для всей системы (например, глобальный счётчик, единая очередь без шардинга), становится потенциальным узким местом. Даже если сегодня нагрузка позволяет использовать единый Redis-инстанс для хранения данных сессий, завтра он может стать барьером масштабируемости. Поэтому предпочтение отдаётся локальному состоянию, дедупликации на уровне приложения, или распределённым координационным механизмам (например, Raft или Paxos — но не как реализация «из коробки», а как осознанный выбор под конкретные требования к согласованности).
Во-вторых, асинхронность как стандарт. Синхронные вызовы легко понимать и отлаживать, но они создают жёсткие временные зависимости и снижают устойчивость к сбоям. Если один сервис отвечает 500 мс дольше обычного, это может вызвать каскадный таймаут в цепочке синхронных вызовов. Асинхронные взаимодействия (через очереди, события, потоки) позволяют изолировать компоненты, буферизовать нагрузку и обеспечить graceful degradation.
В-третьих, измеряемость с самого начала. Масштабируемость нельзя оценить без метрик. Система должна проектироваться так, чтобы в ней были встроены механизмы сбора: времени обработки запроса, глубины очередей, использования CPU и памяти, числа ошибок. Эта информация должна быть доступна для мониторинга и для адаптивного поведения: например, автоматического снижения качества ответа при росте latency, чтобы сохранить доступность.
Наконец, проектирование с расчётом на масштабирование требует явного указания компромиссов. Нельзя одновременно получить максимальную согласованность, доступность и разделение по сети — это практическое следствие CAP-теоремы, с которым мы встретимся чуть позже. Архитектор должен заранее определить, какие свойства являются приоритетными, и документировать это в виде политики: например, «в случае сетевого разделения предпочтение отдается доступности, согласованность достигается асинхронно».
Реструктуризация существующих систем
Проектирование системы, уже находящейся в эксплуатации, — особенно если она функционирует «в плачевном состоянии» — представляет собой задачу иного порядка сложности. Здесь нет чистого листа, но есть работающая (хотя и хрупкая) логика, пользователи, SLA и долговая нагрузка. Такие системы часто страдают от:
- неявных зависимостей (например, скрытые вызовы через общий файл или глобальную переменную);
- «монолитного» разрастания без границ («Big Ball of Mud»);
- отсутствия документации по поведению, а не только по коду;
- несогласованности данных из-за множества точек записи;
- жёсткой привязки к конкретной инфраструктуре (например, к одному серверу БД).
В таких условиях классический подход «переписать всё заново» почти всегда заканчивается провалом: слишком высока стоимость, слишком велика неопределённость, слишком велик риск потери знаний, заложенных в текущем коде.
Вместо этого применяется стратегия поэтапного развязывания зависимостей, известная как Strangler Fig Pattern. Суть подхода — постепенная замена функциональности старой системы новыми, изолированными компонентами, которые подключаются через прослойку (facade, proxy, API gateway). По мере того как старые участки кода заменяются, старая система «задушается» — как фиговое дерево, обвивающее и замещающее старое дерево.
Ключевые шаги реструктуризации:
-
Инвентаризация и картирование — выявление всех входов и выходов системы (API, файлы, события, базы данных), а также построение графа вызовов. Инструменты вроде OpenTelemetry, distributed tracing или даже статического анализа (например, через Roslyn для C# или AST для Python) позволяют восстановить картину, даже если документация отсутствует.
-
Выделение «безопасных» границ — поиск мест, где можно вставить границу между компонентами с минимальным риском. Это могут быть:
- точки ввода/вывода (например, внешние API);
- операции, выполняемые в фоне (обработка файлов, рассылки);
- read-heavy операции, где можно временно допустить eventual consistency.
-
Внедрение прослоек согласования — например, адаптеров между новым и старым кодом, канареечного развёртывания, двойной записи (dual-write) для постепенного перехода на новую схему БД.
-
Параллельная эксплуатация и валидация — новая система работает одновременно со старой, и результаты сравниваются: по метрикам (latency, error rate), по семантике (корректность данных), по нагрузке. Только после подтверждения эквивалентности трафик перенаправляется.
Такой путь долог, но он управляем. Он сохраняет ценность существующей системы, не разрывая контракты с пользователями, и позволяет постепенно заменять «кирпичи» без остановки «здания».
CAP-теорема и её практические интерпретации
CAP-теорема — одна из самых часто цитируемых и при этом наиболее неправильно понимаемых концепций в распределённых системах. Суть её формулировки проста: в условиях сетевого разделения (Partition tolerance — P) невозможно одновременно обеспечить и согласованность (Consistency — C), и доступность (Availability — A). Выбирая два из трёх, проектировщик принимает принципиальное архитектурное решение.
В любой системе, работающей более чем на одном узле, разделение сети — норма. Линии связи могут затормозить, пакеты — потеряться, узлы — перезагрузиться. Поэтому реальный выбор сводится не к «C, A или P», а к поведению системы при разделении: будет ли она отказываться от обработки запросов (жертвуя A в пользу C), или продолжать отвечать, но с возможной несогласованностью (жертвуя C в пользу A).
Однако даже такая трактовка требует уточнений.
Согласованность (C) в контексте CAP — это линейная согласованность (linearizability): каждый оператор чтения должен возвращать результат последней завершённой записи, как если бы все операции выполнялись строго последовательно на одном узле. Это сильная форма согласованности, и она дорого обходится в распределённой среде. Многие системы предпочитают более слабые модели: eventual consistency, causal consistency, session consistency. Это осознанный выбор более слабой семантики в обмен на доступность или задержку.
Доступность (A) в CAP — это строгая форма: каждый запрос к нерабочему узлу должен всё равно получать ответ — успешный или ошибочный — без таймаутов. На практике под «доступностью» часто понимают высокую вероятность ответа (например, 99.99 percent uptime), но CAP требует 100 percent в отсутствие отказов. Это различие критично: система может быть практически доступной, но формально — не-A по CAP.
Наконец, Partition tolerance (P) означает «продолжает функционировать корректно даже при разделении». Корректность здесь — в рамках выбранной семантики: если выбрана C, то при разделении часть узлов должна «замолчать», чтобы не нарушить линейную согласованность.
Практическая интерпретация CAP — проектирование политики реагирования на разделение. Например:
-
Система с приоритетом C (например, банковские транзакции): при сетевом разрыве между кластерами часть запросов будет отклоняться с ошибкой 503, пока разделение не устранено. Это осознанное ограничение доступности для гарантии согласованности.
-
Система с приоритетом A (например, социальный фид): при разделении каждый сегмент продолжает принимать записи и читать локальные копии. Конфликты разрешаются позже (например, с помощью векторных часов или last-write-wins). Это отложенная согласованность как архитектурный приём.
CAP не запрещает гибридные стратегии. Можно проектировать систему, где одни подмножества данных (например, профили пользователей) требуют C, а другие (например, лайки под постами) — A. Это достигается через разделение по доменам и раздельное управление согласованностью.
Шардинг, репликация и консистентное хеширование
Эти три механизма — основные инструменты управления масштабируемостью и отказоустойчивостью в распределённых системах хранения и обработки.
Репликация
Репликация — это создание копий данных на нескольких узлах. Цель — повысить доступность, снизить задержки чтения (за счёт размещения реплик ближе к пользователям) и обеспечить отказоустойчивость.
Существует два основных подхода:
-
Синхронная репликация: запись считается успешной только после подтверждения от всех (или большинства) реплик. Это обеспечивает сильную согласованность, но увеличивает latency и снижает доступность при сбое узлов.
-
Асинхронная репликация: запись подтверждается сразу на ведущем узле, а реплики обновляются позже. Это ускоряет запись и повышает доступность, но создаёт окно, в котором чтение с реплики может вернуть устаревшие данные.
Важно различать ведущую (leader-based) и безлидерную (leaderless) репликацию. В ведущей модели есть один узел, принимающий все записи; в безлидерной — любой узел может принимать и читать/писать. Последняя, как в Dynamo или Cassandra, даёт высокую доступность, но требует сложных механизмов разрешения конфликтов.
Шардинг
Шардинг — это горизонтальное разделение данных по нескольким узлам, при котором каждый узел хранит только часть данных. В отличие от репликации, шардинг повышает пропускную способность (write throughput) и ёмкость, но не обеспечивает отказоустойчивость сам по себе — для этого шарды обычно реплицируются.
Ключевой вопрос шардинга — выбор ключа шардирования. Хороший ключ должен обеспечивать:
- Равномерное распределение нагрузки (avoid hotspots);
- Локальность запросов (чтобы один запрос затрагивал минимальное число шардов);
- Предсказуемость (чтобы можно было определить шард по ключу без глобального каталога).
Пример: в сервисе укорочения URL ключом шардирования может быть хеш от оригинального URL или генерируемый короткий код. Но если использовать просто id % N, то при добавлении нового шарда (N+1) придётся перераспределять все данные — что неприемлемо для больших систем.
Консистентное хеширование
Консистентное хеширование — это алгоритм распределения ключей по узлам, при котором при добавлении или удалении узла перераспределяется только небольшая часть ключей, а не все.
В классическом хешировании (key % N) изменение числа узлов приводит к полной перетасовке. В консистентном хешировании ключи и узлы отображаются на кольцо (виртуальное пространство), и каждый ключ назначается первому узлу по часовой стрелке от его позиции. При добавлении нового узла он «забирает» часть ключей только у одного соседа.
Для устранения неравномерности (например, из-за неравномерного распределения хешей) часто используется виртуальное шардирование: каждый физический узел представляется несколькими виртуальными узлами на кольце. Это позволяет добиться балансировки даже при небольшом числе физических серверов.
Консистентное хеширование лежит в основе таких систем, как Amazon Dynamo, Riak, и используется в балансировщиках нагрузки (например, для sticky sessions без центрального хранилища).
Однако важно понимать: консистентное хеширование не решает проблему согласованности данных. Оно лишь оптимизирует распределение — сама семантика записи и чтения определяется отдельно (например, через quorum-модель: W + R > N, где W — число подтверждённых записей, R — число прочитанных реплик, N — общее число реплик).
Оценка нагрузки: QPS, объём данных, latency
Проектирование масштабируемой системы невозможно без количественной оценки ожидаемой и пиковой нагрузки. При этом важно измерять «средние» значения и процентили (percentiles): 50-й (медиана), 95-й, 99-й, 99.9-й. Пользователь редко замечает среднюю задержку, но почти всегда — хвостовые задержки («почему один запрос занял 5 секунд, хотя обычно — 50 мс`?»).
QPS (queries per second)
Это базовая метрика интенсивности. Однако QPS сам по себе малополезен без контекста:
- Каково соотношение чтений и записей? Записи обычно дороже.
- Какова «тяжеловесность» запроса? Один запрос может читать 1 строку, другой — делать JOIN по миллионам, третий — запускать машинное обучение.
- Есть ли «всплески» (burst)? Система, выдерживающая 1000 QPS равномерно, может упасть под 5000 QPS за 1 секунду.
Поэтому оценка QPS всегда сопровождается профилем запроса: тип, объём затрагиваемых данных, вычислительная сложность.
Объём данных
Важны три измерения:
-
Общий объём — сколько данных будет накоплено за год/пять лет. Это влияет на выбор хранилища (HDD vs SSD), стратегию архивирования, стоимость хранения.
-
Скорость роста — сколько данных добавляется в секунду/минуту. Это определяет пропускную способность каналов записи, частоту компактификации, нагрузку на фоновые процессы (индексация, репликация).
-
Размер отдельной записи/ответа — маленькие записи позволяют эффективно использовать пакетную обработку и кэширование; большие — требуют потоковой передачи, разбиения на чанки, отказа от некоторых оптимизаций (например, in-memory кэширования целых объектов).
Latency
Задержка — это не просто «время отклика». В распределённой системе latency складывается из множества компонентов:
- Сетевая задержка (RTT между клиентом и сервером, между сервисами);
- Время обработки на CPU (включая ожидание в очереди на выполнение);
- Время ожидания ввода-вывода (чтение с диска, ожидание ответа от БД);
- Время сериализации/десериализации (особенно для больших объектов или сложных форматов);
- Время ожидания блокировок (в случае contention на shared state).
Критически важно различать сквозную (end-to-end) latency и компонентную. Например, сервис может отвечать за 100 мс, но 80 мс из них — ожидание ответа от внешнего API. Оптимизация такого сервиса без работы с зависимостью бесполезна.
Хорошая практика — строить латентностный бюджет: выделять долю задержки на каждый этап (например, 20 мс на сеть, 30 мс на БД, 50 мс на обработку). Это позволяет выявлять узкие места до того, как они проявятся в продакшене.
Принципы отказоустойчивости и graceful degradation
Отказоустойчивость — это управляемая деградация в условиях стресса: сбоев компонентов, всплесков нагрузки, сетевых аномалий. Цель — обеспечить приемлемый уровень сервиса даже в нештатных ситуациях.
Ключевой принцип — изоляция отказов. В монолитной системе сбой в одном модуле может привести к падению всего приложения. В распределённой архитектуре отказ должен быть локализован. Для этого применяются:
-
Circuit breaker — паттерн, который «размыкает» вызовы к зависшему или медленному сервису после определённого числа ошибок, возвращая заглушку или кэшированный ответ. Через время происходит «проверка» — если сервис отвечает, цепь замыкается снова. Это предотвращает каскадные отказы.
-
Bulkhead — выделение ресурсов (потоков, памяти, соединений) под отдельные компоненты или типы запросов. Например, в веб-сервере можно выделить отдельный пул потоков для «критических» операций (авторизация, оплата) и отдельный — для «фоновых» (логгирование, аналитика). Если фоновые операции зависнут, критические останутся доступны.
-
Timeout и deadline propagation — каждый вызов должен иметь чёткое ограничение по времени, и это ограничение должно учитываться при каскадных вызовах. Например, если внешний запрос имеет deadline 500 мс, а он вызывает два внутренних сервиса, то на каждый из них может быть выделено не более 200 мс с запасом. Отсутствие deadline’ов ведёт к «зависанию» всей цепочки.
Grateful degradation («благодарное понижение качества») — это архитектурная стратегия. Она предполагает, что система заранее знает, какие функции можно отключить или упростить при росте нагрузки, не нарушая основного сценария.
Примеры:
- Видео-стриминг при нехватке пропускной способности снижает разрешение, но продолжает транслировать звук.
- Поисковая система при перегрузке возвращает результаты без ранжирования по релевантности, но с быстрым временем отклика.
- Веб-приложение при высокой нагрузке отключает «тяжёлые» виджеты (чат, аналитика), оставляя только основной контент.
Важно: graceful degradation должен быть спроектирован и протестирован. Нельзя полагаться на то, что «система сама найдёт выход». Сценарии деградации должны быть явно описаны, реализованы и регулярно проверены (например, через chaos engineering — преднамеренные сбои в staging-среде).
Другой аспект — восстановление после отказа. Здесь важны механизмы (retry, fallback, checkpointing) и политики:
- Когда делать retry? Только для идемпотентных операций. Повторная отправка платежа — риск двойного списания.
- Какой backoff использовать? Экспоненциальный с jitter’ом (случайной задержкой), чтобы избежать синхронных волн повторных запросов.
- Что делать при полном отказе компонента? Переключаться на резервный кластер? Использовать readonly-режим? Отдавать кэшированные данные с пометкой «устаревшие»?
Отказоустойчивость — это культура, а не набор инструментов. Она требует документирования сценариев отказа, регулярных post-mortem’ов без поиска виноватых, и включения «режимов деградации» в планы мониторинга и оповещения.
Примеры архитектур
Рассмотрим три классических случая системного проектирования, каждый из которых иллюстрирует разные аспекты масштабируемости, параллелизма и компромиссов.
1. Picture hosting (сервис хранения изображений)
Типичный пример — аналог Flickr или внутренний CDN для изображений. Основные требования:
- Высокая пропускная способность на чтение (95
percent+ трафика — GET); - Умеренная интенсивность записи;
- Большие объёмы данных (миллионы файлов, терабайты);
- Низкая latency на чтение;
- Отказоустойчивость и долговечность (изображения не должны пропадать).
Архитектурные решения:
-
Шардинг по хешу от имени файла. Например,
sha256(file_id)→ первые 4 символа определяют шард (/0a/3f/...). Это обеспечивает равномерное распределение и локальность. -
Репликация на уровне хранилища. Каждый шард представлен как минимум тремя физическими узлами (например, в разных дата-центрах). Запись требует подтверждения от двух из трёх (quorum
W=2, R=2, N=3), чтение — от одного (R=1), но при конфликте запрашиваются все три (R=3) для разрешения. -
Content-Addressable Storage (CAS). Файл идентифицируется по хешу его содержимого. Это позволяет автоматически дедуплицировать одинаковые изображения (например, аватарки по умолчанию) и проверять целостность при чтении.
-
Многоуровневое кэширование:
- L1: CDN (ближайший POP к пользователю);
- L2: кэш на уровне приложения (например, Redis с ограниченным TTL);
- L3: локальный диск шарда (чтобы избежать повторного чтения с медленного хранилища).
-
Асинхронная обработка метаданных. После загрузки изображения его метаданные (размер, EXIF, миниатюры) обрабатываются в фоне через очередь. Основной ответ приходит сразу — «файл принят», а генерация превью не блокирует интерфейс.
-
Экспирация и архивирование. «Тёплые» данные (активно читаемые) хранятся на SSD; «холодные» (редко запрашиваемые) — перемещаются в объектное хранилище (например, S3 Glacier). Это снижает стоимость.
Особый акцент — на отказе от централизованной БД для хранения путей. Вместо единой таблицы images(id, path, user_id) используется директорная структура на диске или метаданные в заголовках HTTP-ответов. Это устраняет узкое место и позволяет масштабировать чтение почти линейно.
2. URL shortener (сервис укорочения ссылок)
Кажущаяся простота задачи (принять URL → вернуть короткий код → при запросе кода сделать редирект) скрывает серьёзные требования к масштабируемости:
- Очень высокий QPS на чтение (редиректы);
- Умеренный QPS на запись (генерация новых ссылок);
- Строгие требования к latency (редирект должен быть быстрее, чем прямой переход по длинному URL);
- Необходимость уникальности коротких кодов;
- Возможность масштабирования записи (генерация кодов) независимо от чтения.
Архитектурные решения:
-
Генерация ID вне зависимости от БД. Используется распределённый ID-генератор (например, Twitter Snowflake): 41 бит времени, 10 бит идентификатора узла, 12 бит последовательности. Это позволяет генерировать уникальные, почти сортируемые ID без обращения к центральному хранилищу.
-
Преобразование ID в короткий код через base62 (цифры + a-z + A-Z). Например, ID
123456789→1xK5. Код фиксированной длины (например, 6 символов дают ~56 млрд комбинаций). -
Хранение в key-value хранилище. Ключ — короткий код, значение — оригинальный URL. Такие хранилища (Redis, DynamoDB, RocksDB) обеспечивают O(1) чтение с минимальной latency.
-
Кэширование «на лету». Поскольку чтение доминирует, каждый редирект кэшируется на уровне балансировщика (например, nginx с
proxy_cache) или CDN. При первом запросе кода происходит обращение к backend’у, последующие — обслуживаются из кэша. -
Шардинг по префиксу кода. Например, коды
a*,b*, … распределяются по разным шардам. Это позволяет масштабировать запись: генератор ID может направлять новые коды в наименее загруженный шард. -
Read-only реплики для редиректов. Запись идёт в ведущий шард, чтение — в реплики. Это разгружает ведущий узел.
-
Предварительное создание пула кодов. На старте сервис генерирует пул ID (например, 1 млн штук), и выдаёт их по мере поступления запросов. Это исключает contention на генератор.
Важно: в такой системе никакой логики не выполняется при редиректе. Это чистый HTTP 301/302 с указанием Location. Любая дополнительная обработка (аналитика, проверка прав, гео-фильтрация) должна быть опциональной и асинхронной — иначе растёт latency и снижается масштабируемость.
3. Рекомендательные системы
Это наиболее сложный класс систем, где сталкиваются требования к:
- Высокой точности (релевантность рекомендаций);
- Низкой latency (отклик за
<100 мс); - Масштабируемости (миллионы пользователей, миллиарды событий);
- Адаптивности (модели должны обновляться в реальном времени или почти в реальном).
Рекомендательные системы редко строятся как единый компонент. Их разделяют на три слоя:
-
Offline-обработка — обучение глубоких моделей (например, matrix factorization, двух- или трёхслойные нейросети) на исторических данных (покупки, просмотры, лайки). Используются распределённые фреймворки (Spark MLlib, TensorFlow Extended). Результат — набор «сырых» предсказаний (например, оценка вероятности клика для каждого item’а у каждого пользователя).
-
Nearline-обработка — обновление рекомендаций при значимых событиях (например, добавление в корзину, покупка). Используются потоковые движки (Flink, Kafka Streams), которые пересчитывают «ближайший» набор рекомендаций за секунды или минуты.
-
Online-обслуживание — формирование финального списка при запросе. Здесь происходит:
- Выбор кандидатов из нескольких источников (популярное, персонализированное, новое);
- Ранжирование с учётом контекста (время суток, устройство, геолокация);
- Фильтрация (например, исключение уже купленного);
- Применение политик (диверсификация, fairness).
Архитектурные особенности:
-
Feature store — централизованное хранилище признаков (features), используемых и при обучении, и при inference. Это гарантирует consistency между offline и online.
-
Модель как сервис (MaaS) — инференс вынесен в отдельные микросервисы, которые могут масштабироваться независимо. Часто используются оптимизированные runtime’ы (ONNX Runtime, TensorFlow Serving).
-
Кэширование персонализированных списков — для активных пользователей предварительно рассчитанные рекомендации кэшируются (например, в Redis по
user_id). При запросе возвращается кэш, а обновление происходит асинхронно. -
Fallback-стратегии — если персонализированная модель не отвечает, используется:
- Популярное «сейчас» (топ за 24 часа);
- Рекомендации по схожим пользователям (collaborative filtering on-the-fly);
- Случайная выборка из категории.
-
A/B-тестирование на уровне архитектуры — трафик разделяется на сегменты, каждый из которых получает рекомендации от разных моделей. Метрики (CTR, conversion) собираются автоматически, и победившая модель постепенно раскатывается.
Ключевой компромисс в рекомендательных системах — между точностью и latency. Сложная модель может давать +5 percent к CTR, но работать 200 мс. Простая — на 20 мс, но с меньшей точностью. Архитектор должен явно определить, где проходит граница приемлемости — и закодировать это в SLA.